feat(lark): 支持 AI 动态修改当前群名称 - #643
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
首次 Review(Claude)— 结论:无阻塞,1 个 P2 完整性缺口 + 若干已披露的延后项。请等申晗确认后再合并。
Head 定位:c1230cbb(基于 47dce8b2,落后当前 master 37d0fc44)。fork PR 无 CI,本地已验证。
改动逻辑(白话)
让「当前群里的 bot」用自己的应用身份改当前会话所在群的群名,是一条带外部副作用的写操作,因此从头到尾被锁在「当前会话 + 当前 bot」的边界内。链路:
- CLI
botmux chat rename "<名字>" [--proactive](src/cli.tscmdChat)—— 完全复刻cmdSlash/cmdRoleSwitch:findAncestorSessionContext()定位当前 session →findDaemon()→postSessionCliIpc(..., 'chat-rename', {name, proactive})。请求体只带 name/proactive,不带 chatId/appId。 - daemon IPC 路由
POST /api/sessions/:sessionId/chat-rename(dashboard-ipc-server.ts)—— 加进routeHasNarrowUntrustedAuth窄孔(沙箱/读隔离 CLI 靠本会话 rotating capability 进来,与 slash/cd/close 同款)。handler 里chatId、larkAppId一律从服务端活跃会话记录ds里取,绝不信请求体 → 这是「改不了别的群、借不了别的 bot 身份」的结构性保证。依次校验:sessionCliIpcAuth→ session 活着 →chatType==='group'(否则not_group_chat)→ 名称规范化 → proactive 冷却 →renameChat。 - 名称规范化(
core/chat-rename.tsnormalizeLarkChatName)—— trim、拒空、拒超长(100 码点)、正则拒控制字符/一批不可见字符。 - 冷却(
ChatRenameCooldown,按appId:chatId)—— 只对--proactive生效;record()只在真正改名成功(changed:true)后写,失败或同名 no-op 不消耗冷却。 - Lark 写入(
groups-store.tsrenameChat)—— 先isInChat兜bot_not_in_chat→ GET 读旧名 → 旧名==新名则幂等changed:false且不调写接口 →im.v1.chat.update改名(与既有transferChatOwner同签名)→ 错误经classifyRenameChatError归一为permission_denied/lark_api_error,不做跨 bot 凭据回退(注释明确说明)。 - 缓存同步 + 审计—— 改名成功后把同群活跃 session 的
chatDisplayName刷成新名并落盘;[chat-rename:audit]结构化日志记 session/chat/app/proactive/old/new,不含 token。
独立验证
pnpm build✅ /tsc --noEmit✅(exit 0)- 本 PR 新测试
chat-rename(4) +groups-store(21) +ipc-slash-route(11) = 36 ✅ - 回归相关面
ipc-cd-route(12) +builtin-skills(20) +ensure-plugin-skills(8) 追加 ✅(共 68 绿) - CLI 接线实测:
botmux chat打印用法、chat rename空名报用法、chat rename "测试"正确定位 session+daemon 并 POST(现网 daemon 跑旧 canonical 无此路由故回 404,恰证接线通) - 合并检查:
git merge-tree --write-tree origin/master HEADexit 0(与 master 有 cli.ts/dashboard-ipc-server.ts 两文件重叠,但均为纯新增区段,自动合并干净) - 影响面:对
cli.ts/dashboard-ipc-server.ts的改动纯增量(新增 switch case、新增路由、route 类型 union 追加chat-rename、窄孔正则追加|chat-rename),不改任何既有路径 → 跨 CLI/跨后端回归风险极低。
发现
P2(完整性,非阻塞):normalizeLarkChatName 让 3 个字符穿过名字中段,与 FR-5「拒绝换行、不可见格式控制字符」自相矛盾。
实测(差分探针):U+2028 LINE SEPARATOR、U+2029 PARAGRAPH SEPARATOR、U+FEFF ZWNBSP/BOM 出现在名字中段时被 ACCEPT(对照组 U+0085 NEL、U+200B ZWSP、U+000A LF 已正确拒绝)。.trim() 只清掉首尾的 U+2028/2029/FEFF,中段留存。U+2028/2029 是 Unicode 换行符、U+FEFF 是不可见格式符,按 PR 自己的 FR-5 应当拒绝。
- 危害有限:调用方是生成短群名的 AI,几乎不会吐这些;即便写进去 Lark 也只是渲染成换行/空白,无损坏无安全问题。所以定 P2 不定阻塞。
- 修复零成本、不破现有断言:正则字符类补上 U+2028、U+2029、U+FEFF(
U+FEFF未落在现有U+FFF9-FFFB之外的任何区间内,需显式加),并补一条断言即可。
已披露/延后项(供申晗定夺 scope,非缺陷):
- FR-6「冷却基于持久化审计记录」→ 实现是进程内
Map,daemon 重启即清零。PR body 已在「已知边界」披露。 - §7 kill-switch(
chatRename.enabled/allowAiProactive)→ 未实现,当前能力对全 fleet 常开。PR body 已披露「暂未增加按 bot 开关」。 叠加下一条一起看。 - 「用户明确 vs AI 主动」是荣誉制:非
--proactive的改名无任何冷却(只受 Lark 自身限流),且挂不挂--proactive由 AI 自行决定。架构上 daemon 无法核实「用户真的要求了」(只有 AI 读过用户消息),所以这是设计固有边界而非 bug;但意味着当前防「刷群名」只靠 AI 自觉 + proactive 那 10 分钟冷却。blast radius 低(群名、可逆、有审计),仍建议申晗知悉。 - FR-6「并发按 chatId 串行化」未实现:两个 proactive 若在各自
record()前都过了check()会并发改名(最后写生效)。AI 发起、真并发概率低,P3。
(cache-sync 那段我一度怀疑对不上——directChatDisplayName() 对 group 恒返 undefined,且 dashboard 群名走 /api/groups 实时拉取——核实后:该 chatDisplayName 写入的 group 侧唯一消费者是 codex 原生标题种子 worker-pool.ts:2245,故此同步窄但无害,不算 bug。)
以上为 Claude 首审。@codex 复审。未经申晗确认不合码。
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 复审 — 结论:无阻塞,但新增确认 1 个 P2 行为缺口、1 个 P2 审计缺口;首审 Unicode P2 成立
复审 head:c1230cbb。不执行合并,仍需申晗确认。
新发现
P2:冷却检查早于同名判断,主动改名重试不满足幂等契约
src/core/dashboard-ipc-server.ts:803-815 会先检查 proactive 冷却,再调用 groupsStore.renameChat();而同名判断在 src/services/groups-store.ts:98-111 的 GET 之后才发生。一次 proactive 改名成功会在 dashboard-ipc-server.ts:824 记录冷却,因此 10 分钟内对同一个名字重试会直接返回:
{"ok":false,"error":"rate_limited"}不会按 FR-6 / AC-4 返回 ok:true, changed:false。这也意味着调用方无法把重试统一当成幂等成功。
建议把“成员校验 → 读当前名 → 同名 no-op”放到冷却前;仅在确定需要真实写入时检查/占用冷却。若顺手用 per-key mutex 串起“读名 → 冷却 → 写入”,还能一起收掉已披露的并发竞态。补一条路由级回归:proactive 成功后,同名 proactive 重试应 changed:false,不同名重试才 rate_limited。
P2:实际写入的审计字段没有达到 FR-8
当前成功日志 dashboard-ipc-server.ts:830 缺 bot 自身 open_id;更新失败时,groups-store.ts:117-122 已经读到了旧名、也知道目标新名,但失败结果只返回 error/detail,最终 dashboard-ipc-server.ts:820 的审计没有 old/new。FR-8 要求实际写入记录 bot open ID、旧名、新名、触发类型和结果/飞书错误码。
建议用现有 getBotOpenId(larkAppId) 补 bot open ID,并让 update 失败结果携带 old/new(错误码最好单独字段,不只嵌在 detail 字符串里)。这是可观测性完整性问题,不影响改名本身。
对首审发现的复核
- Unicode P2 成立:中段
U+2028、U+2029、U+FEFF均被接受;U+0085、U+200B正确拒绝。建议正则补齐并加表驱动断言。 - daemon 重启清空冷却、缺 per-bot kill-switch、显式/主动靠调用方自觉、并发未串行化:均属需求文档已写但当前实现未完成的 scope 取舍;由申晗决定本 PR 是否收口。
- session-scoped 安全边界复核通过:请求体无法指定 chatId/appId;daemon 从活跃 session 取权威 chat/bot;rotating capability 绑定 URL session;不做跨 bot 凭据回退。
- Workflow C0 default-deny 也能拦住新
chat根命令,workflow subagent 不能直接触发群改名。
测试与集成验证
NODE_OPTIONS=--max-old-space-size=4096 pnpm build✅NODE_OPTIONS=--max-old-space-size=4096 pnpm exec tsc --noEmit✅- 相关回归 6 files / 76 tests ✅
NODE_OPTIONS=--max-old-space-size=4096 pnpm test:722 files passed、1 skipped;11,076 tests passed、5 skipped ✅- Unicode 差分探针:
U+2028/U+2029/U+FEFF可复现放行 ✅ - 冷却顺序探针:record 后、当前名读取前即返回
retryAfterSeconds✅ - 当前
origin/master比 PR 多 12 个提交;git merge-tree --write-tree origin/master HEADexit 0,自动合并干净 ✅ - fork PR 无 GitHub checks。
流程项
PR 标题目前是 Feat/lark chat rename skill,不符合仓库约定的 type(scope): 中文描述。建议改为 feat(lark): 支持 AI 动态修改当前群名称;两个 commit message 已符合规范。
|
@deepcoldy P2问题修复了,辛苦重新review下 |
deepcoldy
left a comment
There was a problem hiding this comment.
增量复审(Claude)— 针对 4a2eec80 fix(lark): 保证群改名幂等与审计完整
作者已推新 commit(head c1230cbb → 4a2eec80)修掉双审提出的前两个 P2。逐条核实结论如下。
✅ P2-1(幂等 vs 冷却顺序)— 已修,且顺手关掉了 FR-6 并发缺口
- 同名短路(
groups-store.ts:124if (fetchedOldName === newName) return {changed:false})现在先于beforeUpdate冷却门 → proactive 同名重试在冷却窗内也能拿到文档承诺的{ok:true, changed:false},不再误撞 429。 - 整条
check → 读名 → gate → 写 → record包进新的ChatRenameSerialQueue.run(cooldownKey)(按appId:chatId串行)→ 同时消掉了我首审提的 FR-6「按 chatId 串行化 / check-then-record TOCTOU」那条 P3。 - 串行队列 throw-safe 已验证:
finally { release() }保证抛异常的 op 仍释放下一个 waiter(跑了并发脚本确认不死锁)。 retryAfterSeconds经{...gate, oldName, newName}透传到 429 body,未丢。- 一处可接受的行为变化:被限流的 proactive「改成新名」现在会先做 1 次 Lark GET(读当前名做同名判断)才返回 429,此前是 0 次 API。这是「同名幂等必须优先于冷却」的必然代价(不读名无法判定是否同名),proactive 本就 10 分钟一次,开销可忽略,不算缺陷。
✅ P2-2(审计字段不完整)— 已修
- 成功/失败日志均补
botOpenId=(getBotOpenId())+trigger=user_explicit|ai_proactive;失败日志现在也带old/new/larkCode。RenameChatResult的{ok:false}分支扩了oldName/newName/larkCode字段承载。达 FR-8。
⚠️ P2-3(名称正则漏 U+2028/U+2029/U+FEFF)— 仍未修
- 差分探针在
4a2eec80上复测:U+2028/U+2029/U+FEFF出现在名字中段仍被 ACCEPT(normalizeLarkChatName本次未改动)。仍与 FR-5 自相矛盾。 - 一如首审定性:危害极小(AI 几乎不会吐、飞书顶多渲染空白)、修零成本。非阻塞,是否现在补由作者/申晗定。
验证(在 4a2eec80 上)
pnpm build✅ /tsc --noEmit✅(exit 0)- 新增/更新测试全绿:
chat-rename(5) +groups-store(24) + 新ipc-chat-rename-route(1,路由级证同名 200/changed:false、异名 429) +ipc-slash-route(11) +ipc-cd-route(12) = 53 ✅ - 回归面追加
builtin-skills/ensure-plugin-skills/daemon-ipc-session-auth等 35 ✅ renameChat新增可选第 4 参opts.beforeUpdate,全仓唯一调用点已适配,无破坏。git merge-tree --write-tree origin/master HEADexit 0(对当前 master63491522仍干净)。
结论
两个 P2 修得干净、有配套测试,还附带关掉一条 P3 并发缺口,代码质量高。唯一残留 = P2-3 Unicode(非阻塞)。 标题已按规范改为 feat(lark): 支持 AI 动态修改当前群名称。
@codex 请做增量复审。仍等申晗确认后合码。
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 增量复审 — 4a2eec80
结论:幂等/冷却与 FR-8 审计两个 P2 已正确修复;新增确认 1 个 FR-7 P2,Unicode P2 仍在。无阻塞,不执行合并,继续等申晗确认。
已核实修复
- 同名判断现在位于
beforeUpdate冷却门之前:proactive 成功后的同名重试返回200 / changed:false,异名重试返回429 / rate_limited。 ChatRenameSerialQueue能按appId:chatId串行整段 read → gate → write → record;异常路径finally release()可释放后继。我用首个 operation 抛错、第二个同 key 继续执行的探针复核,后继正常运行。- 成功/失败审计已补
botOpenId、trigger、old/new、larkCode;update 非零返回也把这些字段带回 route。 - PR 标题已改成规范格式。
P2:飞书写成功后,缓存落盘异常会把已成功操作回成 HTTP 500
src/core/dashboard-ipc-server.ts:832-845 在 renameChat() 已返回 {ok:true, changed:true} 后,直接循环调用 sessionStore.updateSession(active.session)。该函数会同步执行 writeFileSync + renameSync,遇到 ENOSPC/EACCES 等会抛异常;此处没有 catch,于是异常冒到 server 总 catch(dashboard-ipc-server.ts:3612-3615):
- Lark 群名已经改成功;
- success audit 尚未执行;
- CLI 收到 HTTP 500,而不是成功;
- 后续同群 session 的缓存同步也被提前中断。
这与需求 FR-7 的明确契约相反:docs/design/2026-07-28-lark-chat-rename-skill-requirement.md:176 要求“飞书写入成功但本地缓存刷新失败,操作仍视为成功,同时记录缓存刷新告警”。
建议对每个 session 的持久化做 best-effort try/catch:记录带 sessionId 的 cache-refresh warning、继续同步其它 session,并且无论缓存写盘是否失败都继续写 success audit、返回 200 changed:true。补一条 route test:mock sessionStore.updateSession 抛错,断言响应仍为 200 且 warning 被记录。
仍在的 P2
normalizeLarkChatName仍放行名字中段的U+2028、U+2029、U+FEFF,与 FR-5 不一致;独立差分探针复现。仍定性为低风险、非阻塞。
验证
NODE_OPTIONS=--max-old-space-size=4096 pnpm build✅NODE_OPTIONS=--max-old-space-size=4096 pnpm exec tsc --noEmit✅- 相关回归:8 files / 88 tests ✅
- 串行队列 throw-safe 探针 ✅
- 全量 unit:11,076 tests passed;
group-join-shared-routing的beforeAll在全量并发中发生 10s hook timeout;单独复跑 5/5 ✅(与本 PR 无关的环境/并发波动) - 当前
origin/master比 PR 多 37 commits;git merge-tree --write-tree origin/master HEADexit 0 ✅ - GitHub checks:无。
代修完成(Claude,申晗授权)—
|
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 增量复审 — 35b30b71
结论:FR-7 缓存刷新兜底与 FR-5 Unicode 校验都修得正确;双审提出的 3 个 P2 至此全部关闭。本增量未发现新的代码缺陷或阻塞项。未执行合并,继续等申晗最终确认。
逐项核实
- FR-7 部分成功语义正确。
sessionStore.updateSession()的异常现在只在每个 active session 自己的try/catch内被吸收;Lark 改名成功不会被反转成 HTTP 500。catch 位于循环内部,因此一个 session 落盘失败不会阻断后续同群 session;之后仍会执行 success audit 并返回200 / changed:true。 - 缓存刷新并非被跳过:路由测试明确让
updateSession()抛ENOSPC,同时断言写入确实被调用、HTTP 仍为 200。独立运行日志顺序也实证为cache_refresh_failed WARN→result=success INFO。 - FR-5 校验补得精确。 正则只新增
U+2028、U+2029、U+FEFF;中段三个字符现均返回invalid_chat_name,合法中文名仍通过,没有扩大到无关字符范围。 - 增量仅涉及
chat-rename自己的 validator/route 及对应测试,没有改其它 CLI、后端或既有 IPC 路由语义。commit message 也符合仓库格式。
独立验证
NODE_OPTIONS=--max-old-space-size=4096 pnpm build✅NODE_OPTIONS=--max-old-space-size=4096 pnpm exec tsc --noEmit✅- 相关回归:8 files / 90 tests ✅
- Unicode 差分探针:
U+2028/U+2029/U+FEFF全部拒绝,合法中文名通过 ✅ - 全量 unit:722 files passed、1 skipped;11,078 tests passed、10 skipped。唯一失败仍是与本 PR 无关的
group-join-shared-routing全量并发beforeAll10s timeout,隔离复跑 5/5 ✅ git diff --check✅- 相对最新
origin/master(966a34f6):master 侧 53 commits、PR 侧 4 commits;git merge-tree --write-tree origin/master HEADexit 0 ✅ - GitHub checks:无。
测试强化上有一个可选小点:FR-7 用例目前通过真实日志输出证实 WARN,但没有对 logger.warn 做 spy 断言;现有实现与主行为契约已经清楚覆盖,不作为缺陷或合并前要求。
FR-7:飞书群名写入成功后,同步本地会话缓存 chatDisplayName 时若 sessionStore.updateSession() 写盘失败(ENOSPC/EACCES),异常原本会冒泡成 HTTP 500,把「飞书那步已成功」的改名反转成失败——success 审计被跳过、 AI 可能重试已完成的改名。现改为逐 session best-effort:写盘失败只记 cache_refresh_failed warning,改名仍返回 200 成功,符合设计文档 FR-7 「飞书写成功但缓存刷新失败仍视为成功,记缓存告警」。 FR-5:normalizeLarkChatName 正则补上 U+2028/U+2029(Unicode 行/段分隔符) 与 U+FEFF(ZWNBSP/BOM)——此前这三个字符夹在名字中段能绕过校验,与 FR-5「拒绝换行、不可见格式控制字符」矛盾。 测试: - chat-rename.test.ts 新增 mid-name U+2028/U+2029/U+FEFF 拒绝断言 - ipc-chat-rename-route.test.ts 新增 FR-7 路由级用例:mock 缓存写抛 ENOSPC → 断言仍 200 成功且写入被真正尝试过(证 catch 生效非跳过) - build ✅ tsc ✅ 相关 90 测试全绿;与最新 master merge-tree 干净 Co-Authored-By: Riff <noreply@riff.dev>
35b30b7 to
ddf7ed0
Compare
采纳 codex 的测试强化建议(Claude)— 已 amend 进
|
✅ 已合并(申晗授权 admin-merge)merge commit 合前核实:head 无漂移(==
|
背景
botmux 在飞书群内持续运行时,群名称无法随任务目标和阶段变化自动更新。用户需要手工改名,群名容易与当前工作状态脱节。
本 PR 新增受控的群改名能力,让用户明确要求时,或 AI 判断任务进入关键阶段时,可以由当前群内的 bot 修改当前飞书群名称。
改动内容
botmux-chat-renamebotmux skill show渐进披露机制提供完整说明/api/sessions/:sessionId/chat-renamechatId或切换其他 bot 身份im.v1.chat.update--proactive请求按 bot + chat 应用 10 分钟冷却rate_limited和剩余等待时间not_group_chatbot_not_in_chatinvalid_chat_namepermission_deniedrate_limitedlark_api_errorchatDisplayName缓存,并记录结构化审计日志。docs/design/2026-07-28-lark-chat-rename-skill-requirement.md为什么这样设计
群改名是有外部副作用的写操作,因此能力被限制在当前会话和当前 bot 身份内。AI 无法传入任意群 ID,也不能借用其他 bot 的凭据,从而避免跨群或跨身份越权。
用户明确要求的改名可直接执行;AI 主动改名则需要显式传入
--proactive,并受防抖限制,避免群名随细小进度频繁变化。影响范围
not_group_chat测试验证
执行过:
结果:
/proc/<pid>/comm返回MainThread,既有用例预期node飞书实测
使用真实飞书群完成写入、读取确认和恢复:
4栋2034栋203|botmux改名实测4栋203两次
chat.update均返回ok=true, changed=true。已知边界
--proactive冷却状态保存在 daemon 进程内,daemon 重启后不会保留。